---
title: "Student Flow"
type: concept
created: 2026-04-18
updated: 2026-04-18
sources: ["raw/articles/11-user-signup-flows.md", "raw/articles/05-roster.md"]
tags: [user-flows, student, child, login, placement-test, telemetry]
---

# Student Flow

Students (children, ages 4–11) are the core users of the [[Reader App]]. Their accounts are created entirely by teachers — children have no self-registration path.

## Account Creation (Teacher Side)

A student account is born when a teacher adds them to a class. See [[concepts/user-flows/Teacher Flow]] for the full add/import flow. The output is:

- A row in `students` table with:
  - `uuid` (the universal `learner_id` across all services)
  - `username` (e.g., `sofia001`)
  - `pin_hash` (bcrypt of 4-digit PIN)
  - `state: 'created'`
  - `placement_test_completed: false`

## First Login

```mermaid
flowchart TD
    L["Child at app.readingtester.com/login\nEnters username + 4-digit PIN"]
    CK{"Account locked?\nstudents.locked = true"}
    L --> CK
    CK -->|yes| ERR1["423: Account locked\nAsk your teacher"]
    CK -->|no| AUTH{"PIN correct?\nbcrypt compare"}
    AUTH -->|wrong| FAIL["401: Wrong PIN\nIncrement failed_attempts"]
    FAIL --> LOCK{"failed_attempts ≥ 5?"}
    LOCK -->|yes| LOCKACT["students.locked = true\nTeacher must reset"]
    LOCK -->|no| L
    AUTH -->|correct| RESET["failed_attempts = 0\nSet reader_session cookie"]
    RESET --> PT{"placement_test_completed?"}
    PT -->|false| PLACE["/placement-test"]
    PT -->|true| LIB["/library"]
```

**5× PIN lock:** After 5 consecutive wrong PIN entries, the account is locked. Only a teacher can reset the PIN and unlock the account. The child sees "Account locked. Ask your teacher."

**Session cookie:** `reader_session` scoped to `app.readingtester.com` only. Children never touch the Account Center.

---

## Session Policy Model

> **⚠️ Non-Negotiable (Sig, 2026-04-18):**
> - `learner_id` is always a child's UUID — never an integer, never a device ID
> - Identity is never inferred from device
> - Session **strictness** is determined by: login type + device trust state + school policy
> - Device signals are heuristics only — they escalate policy, they never determine identity

---

### Canonical Rule: Login Type → Default Trust

| Login Type | Default Trust | Rationale |
|---|---|---|
| Child PIN login | **Shared-device risk** — strict policy applied by default | PIN is low-friction; device may be school iPad used by many children |
| Teacher/parent password login | **Personal-device eligible** — relaxed policy available | Password + adult account = reasonable personal-device assumption |

This is the baseline. School policy and device trust state can override it.

---

### Device Trust

- Only **adult accounts** (teacher, parent, school_admin) can mark a device as trusted
- Child accounts **cannot** use trusted-device mode unless explicitly enabled by school policy (`allow_child_trusted_devices = true`)
- Trust is stored as a signed cookie (`device_trust`) scoped to the domain, with a school-configurable TTL (default 90 days for adults)
- Trusted device = relaxed inactivity timeout + no forced re-auth on resume
- Untrusted device (default for all child logins) = strict policy always applied

---

### School Policy Settings

Stored in `school_settings` table per `school_id`:

```sql
ALTER TABLE school_settings ADD COLUMN (
  shared_device_mode          BOOLEAN NOT NULL DEFAULT TRUE,   -- enforce strict child session policy
  child_inactivity_timeout_min INT NOT NULL DEFAULT 30,        -- range: 10–60
  allow_personal_child_devices BOOLEAN NOT NULL DEFAULT FALSE  -- allow child trusted-device mode
);
```

| Setting | Default | Effect |
|---|---|---|
| `shared_device_mode` | `TRUE` | Strict inactivity timeout + threshold save rules enforced for all child sessions |
| `child_inactivity_timeout_min` | `30` | Minutes of inactivity before child session auto-closes. Range: 10–60 |
| `allow_personal_child_devices` | `FALSE` | If `TRUE`, children may use trusted-device cookies (school explicitly opts in) |

---

### Device Signals — Heuristics Only

The following signals **may trigger escalation to shared-device policy** even if a trusted cookie is present. They are hints — not identity assertions.

| Signal | Trigger Condition |
|---|---|
| Multiple child logins on same browser | >1 distinct `learner_id` logged in within a rolling 4-hour window on this browser |
| No trusted cookie present | Child session has no `device_trust` cookie |
| Frequent session switching | >3 child session switches within 60 minutes on this browser |
| Managed / kiosk device flag | `X-Device-Mode: kiosk` header sent by MDM (optional, school-configured) |

**On signal triggered:** escalate to shared-device policy for that session. Log `device_policy_escalated` to `audit_log`. Do not block the child — just apply stricter timeout and threshold rules.

```typescript
function resolveSessionPolicy(schoolSettings: SchoolSettings, deviceContext: DeviceContext): SessionPolicy {
  // Start from login-type default
  let policy: SessionPolicy = deviceContext.loginType === 'child_pin'
    ? SHARED_DEVICE_POLICY
    : PERSONAL_DEVICE_POLICY;

  // Apply school policy overrides
  policy.inactivityTimeoutMin = schoolSettings.child_inactivity_timeout_min;
  if (!schoolSettings.shared_device_mode) {
    policy = PERSONAL_DEVICE_POLICY;  // school explicitly disabled shared-device mode
  }

  // Escalate on device signals (heuristics only — never downgrade trust)
  if (deviceContext.hasSharedSignals) {
    policy = SHARED_DEVICE_POLICY;
    policy.inactivityTimeoutMin = schoolSettings.child_inactivity_timeout_min;
    auditLog.write({ action: 'device_policy_escalated', metadata: deviceContext.signals });
  }

  return policy;
}
```

---

### Session Ownership

Every reading session is bound to **two** identifiers:
- `learner_id` — child's UUID (permanent identity)
- `device_session_id` — a short-lived token issued at login, scoped to this device-login pair

Telemetry events reference `device_session_id`. The Reader App resolves `device_session_id → learner_id` server-side. Device identity (IP, browser fingerprint) is **never** used as a learner identity proxy.

```sql
CREATE TABLE device_sessions (
  id              VARCHAR(36)  NOT NULL PRIMARY KEY,
  learner_id      VARCHAR(36)  NOT NULL,
  policy_applied  ENUM('shared','personal') NOT NULL,
  policy_source   VARCHAR(50)  NOT NULL,   -- 'login_type' | 'school_policy' | 'device_signal'
  created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  last_active     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  expired_at      DATETIME     NULL,
  end_reason      ENUM('logout','timeout','device_reuse','force_abandon') NULL,
  INDEX idx_learner_id (learner_id)
);
```

---

### Partial Session Rules on Timeout — Confirmed (Sig, 2026-04-18)

Applies when `policy_applied = 'shared'` and session closes due to inactivity or device reuse.

| Condition | Outcome |
|---|---|
| `pages_read ≥ 3` AND `words_counted ≥ 50` | Session saved — miles counted, progress saved, telemetry fires to bot |
| Below threshold | Session discarded — no miles, no bot signal, audit log written |
| `end_reason` | Always written regardless of save/discard |

Personal-device sessions (`policy_applied = 'personal'`): saved unconditionally on timeout — no threshold applied.

```typescript
async function closeTimedOutSession(deviceSessionId: string) {
  const session = await getOpenSession(deviceSessionId);

  await db.device_sessions.update({
    where: { id: deviceSessionId },
    data: { expired_at: new Date(), end_reason: 'session_timeout' }
  });

  if (session.policy_applied === 'personal') {
    // Personal device — always save
    await telemetry.sessionEnded(session, { end_reason: 'session_timeout' });
    return;
  }

  // Shared policy — threshold check
  const meetsThreshold = session.pages_read >= 3 && session.words_counted >= 50;
  if (meetsThreshold) {
    await telemetry.sessionEnded(session, { end_reason: 'session_timeout' });
  } else {
    await auditLog.write({
      action: 'session_discarded_below_threshold',
      subject_id: session.learner_id,
      metadata: { pages_read: session.pages_read, words_counted: session.words_counted }
    });
  }
}
```

---

### Device Reuse (Next Child Logs In)

1. Previous `device_session_id` marked `expired_at = NOW()`, `end_reason = 'device_reuse'`
2. Partial session rules applied (threshold check if shared policy)
3. New `device_session_id` issued for next child on PIN confirmation
4. Next child's session starts clean — no state carried over

**Order guarantee:** Steps 1–2 complete before Step 3 is issued. Transactional — a child can never inherit an unclosed session.

---

### Quick-Switch Login UX

- Login screen pre-fills last username for speed; fresh PIN always required
- PIN is the only child auth step — no Account Center redirect
- Failed PIN: `failed_attempts` incremented, locked after 5 failures
- On successful PIN: existing open session on device closed first (steps 1–2 above), then new session opened

## Placement Test

The placement test runs on first login only. It determines the child's starting FK reading level.

**Flow:**
1. Child reads 4–8 adaptive passages of varying difficulty
2. Answers comprehension questions after each
3. Results scored: FK level calculated from passage difficulty + accuracy
4. `POST /api/placement-result { learner_id, fk_level, raw_scores }`
5. Writes to `placement_results` table
6. `students.placement_test_completed = true`
7. Fires to [[Learner Bot]]: `POST /api/v1/bot/:learner_id/placement-result` (X-Internal-Key)
8. Redirect to `/library`

**Output:** Child's starting `reading_level` (DECIMAL 4,2) — used by all subsequent FK leveling requests to the [[Adaptive Engine]].

**Inference fallback:** If the child has prior Fluency Assessment data (e.g., from a school that uses Fluency Assessment before the app), the system uses that FK/CEFR level instead of running a fresh placement test. `students.placement_inferred = true` in this case.

⬜ **NOT YET BUILT:** The placement test UI is not yet built (2026-04-18).

## Library

After placement, the child lands in the library:

- **"Picked for You"** — 8 personalized book recommendations from the [[Content Service]] (level ±1 + interest tags, Phase 1 rules-based)
- **Browse** — full library filtered by level and interests
- **Assigned** — books assigned by teacher (when lesson book pipeline is live)

Book covers show reading level indicators appropriate for the child's age (no FK numbers shown to children — shown as stars or icons).

## Reading a Book

```mermaid
flowchart LR
    BO["book_opened event\n→ Telemetry"]
    PT["Page rendered\nAdaptive Engine\nlevels page to child FK"]
    FF["Finger-Follow reading\nWords highlight #FCC128\nTTS speaks on touch\n100 words = 1 mile"]
    WT["word_tapped events\n→ Telemetry\n→ Vocab gaps"]
    NP["page_turned event\n→ Telemetry\n(time_on_page_ms)"]
    SE["session_ended event\n→ Telemetry\n→ Learner Bot\n(vocab-taps, session-summary)"]
    BO --> PT --> FF --> WT
    FF --> NP --> PT
    FF --> SE
```

**Finger-Follow:** Child drags finger over words. Words highlight in `#FCC128`. TTS speaks each word on touch (120ms debounce, iOS audio gate). Each word counted toward Miles.

**Miles + Tokens:** 100 words = 1 mile. 10 miles = 1 token. Tokens unlock books (not prizes). UI reward screen: ⬜ not yet built.

**Karaoke TTS:** Word-by-word highlight. Speed 0.5×–1.5× (0.75× default). Already-read text dims.

**Speedread RSVP:** Ages 8+ and FK ≥ 3.0 only. Max 10 minutes. Never default. Opt-in. Words in RSVP count toward miles/practice only — NOT toward "books read" count.

**Word tap vocabulary popup:** Tapping any word shows a definition. Logged as `word_tapped` event. Feeds [[Learner Bot]] vocabulary gap tracking.

**Offline support:** Service Worker queues all events in IndexedDB if offline. Replays on reconnect (max 72h queue, exponential backoff).

## Reading Completion

When the child finishes the book (`session_ended` event):

1. Telemetry service receives `session_ended`
2. Telemetry fires to Learner Bot: vocab-taps summary + session-summary (HTTP POST, 5s timeout, fire-and-forget)
3. `students.miles` and `students.tokens` updated in Reader App DB
4. LRS receives xAPI statement: `{actor: learner_id, verb: completed, object: book_id, result: {score, duration}}`
5. Comprehension quiz shown (⬜ not yet built)

## Telemetry Events

All events are sent to `telemetry.readingtester.com` (port 3110) via HTTP POST:

| Event | When emitted | Key fields |
|---|---|---|
| `book_opened` | On book open | book_id, session_id |
| `page_turned` | On each page turn | page_number, time_on_page_ms |
| `word_tapped` | On word tap | word, page_number |
| `session_ended` | On book finish or abandon | session_id |
| `book_abandoned` | On exit mid-book | page_number |

Deduplication: `event_id` (UUID, client-generated) has a UNIQUE constraint. Duplicate → silently ignored (200, not 409).

## Related Pages

- [[concepts/user-flows/index|User Flows]] — all roles overview
- [[concepts/user-flows/Teacher Flow]] — how the student account is created
- [[entities/Reader App]] — the student-facing reading application
- [[entities/Telemetry Service]] — event collection service
- [[entities/Learner Bot]] — processes student data nightly
